<?php
/*
 * Copyright (c) 2013 Eugene Kuzmenko
 *
 * Permission  is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in  the Software without restriction, including without limitation the rights
 * to  use,  copy,  modify,  merge, publish, distribute, sublicense, and/or sell
 * copies  of  the  Software,  and  to  permit  persons  to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The  above  copyright  notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE  SOFTWARE  IS  PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED,  INCLUDING  BUT  NOT  LIMITED  TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS  FOR  A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS  OR  COPYRIGHT  HOLDERS  BE  LIABLE  FOR  ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 *
 * For more information, see <http://www.aljey.com>.
 */

namespace Al\Less\Lib;

use Al\Core\Exception\FileNotFoundException,
    Al\Template\Lib\AbstractTemplateEngine,
    Al\Core\Lib\Service;

/**
 * Renders Less CSS files as plain old css, employs automatic caching to improve
 * performance.
 * Allows you to configure Less CSS parameters as AlWEB yaml parameters:
 * <code>
 * // params.yml
 * less:
 *   black: "#000"
 * </code>
 * The parameter will now be available in every less file as @black.
 *
 * @author Eugene Kuzmenko <mail@aljey.com>
 */
class LessTemplateEngine extends AbstractTemplateEngine {

  public $type = 'css';

  /**
   * @var \Less_Parser An instance of the less parser.
   */
  private $less;

  /**
   * @var array The micro-cache metadata array.
   */
  private $meta = array();

  /**
   * @var array Image mime types.
   */
  private $mime = array(
    'image/png' => 'png',
    'image/vnd.wap.wbmp' => 'wbmp',
    'image/webp' => 'webp',
    'image/x-xbitmap' => 'xbm',
    'image/x-xpixmap' => 'xpm',
    'image/gif' => 'gif',
    'image/bmp' => 'string',
    'image/jpeg' => 'jpeg',
  );

  /**
   * @var string The location at which to store the sprite images.
   */
  private $sprite_dir;

  /**
   * Constructor
   */
  public function __construct() {
    $this->sprite_dir = __DIR__ . '/../Resources/public/img/sprite';
    $OyejorgeLessPHP = __DIR__ . '/../Vendor/OyejorgeLessPHP/lib/';
    Service::get('event')->on('autoload.fallback', function ($class)
      use (&$OyejorgeLessPHP) {
        if (strpos($class, 'Less_') !== 0) return false;
        if(file_exists($fileName = $OyejorgeLessPHP . str_replace('_', '/',
            $class) . '.php')){
          require $fileName;
          return true;
        }
      });
  }

  /**
   * {@inheritDoc}
   * Minifies and returns the result.
   */
  public function render($template, $params = array()) {
    if (!is_file($template)) {
      throw new FileNotFoundException($template);
    }
    $template = realpath($template);
    $hash = md5($template);
    $cache = Service::get('cache');
    if ($this->isModified($template)) {
      $content = $this->resolveSprites($template,
        $this->resolveImports($template));
      $less = $this->compileLess($template, $content, $params);
      $cache['less'] = $this->getMeta();
      $cache[$hash] = $less;
    }
    $this->result = $cache[$hash];
  }

  /**
   * Compiles Less to CSS.
   *
   * @param string $filename
   * @param string $content Less
   * @param array $params [optional]
   * @return string CSS
   */
  public function compileLess($filename, $content, $params = array()) {
    $filename = realpath($filename);
    $hash = md5($filename);
    $config = Service::get('config.params.less', array());
    $meta = &$this->getMeta();
    $values = array();
    foreach ($params + $config as $key => $value) {
      try {
        $value = (string)$value;
        $values[$key] = $value;
      } catch (\Exception $e) {}
    }
    $meta[$hash]['params'] = $config;
    return $this->getLess()->parse($content)->ModifyVars($values)->getCss();
  }

  /**
   * Parses a less file to resolve all the @import calls.
   *
   * @param string $filename The path to a less file.
   * @return string Less CSS data string.
   * @throws FileNotFoundException
   */
  public function resolveImports($filename) {
    $filename = realpath($filename);
    $hash = md5($filename);
    $dirName = dirname($filename);
    $meta = &$this->getMeta();
    $meta[$hash]['time'] = filemtime($filename);
    $meta[$hash]['map'] = array();
    $meta[$hash]['params'] = array();
    $config = Service::get('config');
    $me = $this;
    return preg_replace_callback('/( *)@import +"([^\s]+)"/',
      function ($matches) use ($dirName, &$meta, $hash, &$me, &$config) {
        $parts = array();
        if (preg_match('/^al:\/\/([^\s]+)/', $matches[2], $parts)) {
          if (!$parts[1] || (3 !== count($tplParts = explode(':',
              $parts[1])))) {
            throw new FileNotFoundException($parts[1]);
          }
          $dir = trim($tplParts[1], '/');
          if (empty($dir)) {
            $dir = 'Resources/view/less';
          }
          $plugin_name = trim($tplParts[0]);
          if (empty($plugin_name)) {
            $root = "../app/$dir";
            if (!($filename = realpath("$root/$tplParts[2]")) &&
                !($filename = realpath("$root/$tplParts[2].less"))) {
              throw new FileNotFoundException($parts[1]);
            }
          } else {
            if (!($root = $config->getPluginLocation($plugin_name))) {
              $root = '../src/' . str_replace('\\', '/', $plugin_name);
            }
            $root .= "/$dir";
            if (!($filename = realpath("$root/$tplParts[2]")) &&
                !($filename = realpath("$root/$tplParts[2].less"))) {
              $root = '../app/' . str_replace('\\', '', $plugin_name) . "/$dir";
              if (!($filename = realpath("$root/$tplParts[2]")) &&
                  !($filename = realpath("$root/$tplParts[2].less"))) {
                throw new FileNotFoundException($parts[1]);
              }
            }
          }
        } elseif (!($filename = realpath("$dirName/$matches[2]")) &&
            !($filename = realpath("$dirName/$matches[2].less"))) {
          throw new FileNotFoundException("$dirName/$matches[2]");
        }
        if (!in_array($filename, $meta[$hash]['map'], true)) {
          $meta[$hash]['map'][] = $filename;
        }
        return $me->resolveImports($filename);
      }, file_get_contents($filename));
  }

  /**
   * Processes sprite mixins (.sprite, .spritex, .spritey). The image used in a
   * sprite cannot exceed 1000px in either width or height.
   *
   * @param string $filename The path to a less file.
   * @param string $content The less css string with the resolved @import calls.
   * @return string mixed The less css string with processed sprite mixins.
   * @throws \BadMethodCallException
   */
  public function resolveSprites($filename, $content) {
    $filename = realpath($filename);
    $hash = md5($filename);
    $dirName = dirname($filename);
    $meta = &$this->getMeta();
    foreach (array('sprite', 'spritex', 'spritey') as $sprite_type) {
      $matches = array();
      preg_match_all('/\.' . $sprite_type . '\([\'"]?([^\)\'"]+)[\'"]?\);?/',
        $content, $matches);
      if (!count($matches[1])) {
        continue;
      }
      $input = array();
      foreach ($matches[1] as $key => $file) {
        if ($file = realpath("$dirName/$file")) {
          if (!isset($input[$file])) {
            $input[$file] = array();
          }
          $input[$file][] = $matches[0][$key];
        }
      }
      $files = array();
      foreach ($input as $file => $matches) {
        if (($size = getimagesize($file)) && array_key_exists($size['mime'],
            $this->mime) && ($size[0] <= 1000) && ($size[1] <= 1000)) {
          $files[] = array(
            'file' => $file,
            'width' => $size[0],
            'height' => $size[1],
            'type' => $this->mime[$size['mime']],
            'matches' => $matches,
          );
          if (!isset($meta[$hash][$sprite_type])) {
            $meta[$hash][$sprite_type] = array();
          }
          $meta[$hash][$sprite_type][] = $file;
        } else {
          $content = str_replace($matches, '', $content);
        }
      }
      if (!count($files)) {
        continue;
      }
      if (!extension_loaded('gd') || !function_exists('gd_info')) {
        throw new \BadMethodCallException('The php GD library is required to ' .
          'enable the automatic sprite generation!');
      }
      @mkdir($this->sprite_dir, 0777, true);
      $content = $this->$sprite_type($files, $content);
    }
    return $content;
  }

  /**
   * Returns an initialized Less_Parser object.
   *
   * @return \Less_Parser
   */
  private function getLess() {
    if (!$this->less) {
      $this->less = new \Less_Parser(array('compress' => true));
    }
    return $this->less;
  }

  /**
   * Returns the automatic micro-cache metadata.
   *
   * @return array The micro-cache metadata array.
   */
  private function &getMeta() {
    if (!$this->meta) $this->meta = Service::get('cache.less', array());
    return $this->meta;
  }

  /**
   * Checks whether the file or one of the files that it @import's has been
   * modified since the last check.
   *
   * @param string $filename The path to a less file.
   * @return boolean Has the file been modified?
   */
  private function isModified($filename) {
    $filename = realpath($filename);
    if (!$filename) {
      return true;
    }
    $hash = md5($filename);
    $meta = &$this->getMeta();
    if (!isset($meta[$hash])) {
      $meta[$hash] = array(
        'time' => 0,
        'map' => array(),
        'params' => array(),
        'sprite' => array(),
        'spritex' => array(),
        'spritey' => array(),
      );
      return true;
    }
    if (!isset($meta[$hash]['params'])) {
      $meta[$hash]['params'] = array();
    }
    foreach ($meta[$hash]['params'] as $key => $value) {
      if ($value != Service::get("config.params.less.$key")) {
        return true;
      }
    }
    if (!isset($meta[$hash]['time'])) {
      $meta[$hash]['time'] = 0;
    }
    $time = filemtime($filename);
    if ($time > $meta[$hash]['time']) {
      $meta[$hash]['time'] = $time;
      return true;
    }
    if (!isset($meta[$hash]['map'])) {
      $meta[$hash]['map'] = array();
    }
    foreach ($meta[$hash]['map'] as $value) {
      if ($this->isModified($value)) {
        return true;
      }
    }
    foreach (array('sprite', 'spritex', 'spritey') as $sprite_type) {
      if (isset($meta[$hash][$sprite_type]) &&
          count($meta[$hash][$sprite_type])) {
        if (!($sprite = realpath("$this->sprite_dir/$sprite_type.png"))) {
          return true;
        }
        foreach ($meta[$hash][$sprite_type] as $icon) {
          if ($this->isModifiedIcon($icon)) {
            return true;
          }
        }
      }
    }
    return false;
  }

  /**
   * Checks whether an icon used in a sprite has been modified.
   *
   * @param string $icon The path to an icon image.
   * @return bool Has the icon been modified since the last check?
   */
  private function isModifiedIcon($icon) {
    $icon = realpath($icon);
    if (!$icon) {
      return true;
    }
    $hash = md5($icon);
    $meta = &$this->getMeta();
    if (!isset($meta[$hash])) {
      $meta[$hash] = 0;
    }
    $time = filemtime($icon);
    if ($time > $meta[$hash]) {
      $meta[$hash] = $time;
      return true;
    }
    return false;
  }

  /**
   * Creates and returns an image resource.
   *
   * @param int $width The width of a new image.
   * @param int $height The height of a new image.
   * @return resource An image resource.
   */
  private function &createImage($width, $height) {
    $img = imagecreatetruecolor($width, $height);
    $background = imagecolorallocatealpha($img, 255, 255, 255, 127);
    imagefill($img, 0, 0, $background);
    imagealphablending($img, false);
    imagesavealpha($img, true);
    return $img;
  }

  /**
   * Saves the image to a file and disposes of of the resource.
   *
   * @param resource $img An image resource.
   * @param string $sprite_type A file name without an extension.
   */
  private function saveImage(&$img, $sprite_type) {
    imagepng($img, "$this->sprite_dir/$sprite_type.png", 9, PNG_ALL_FILTERS);
    imagedestroy($img);
  }

  /**
   * Creates and returns an image resource for a given icon.
   *
   * @param array $config The information about an icon
   *                      (@see this::resolveSprites)
   * @return resource An image resource.
   */
  private function &createTemporaryImage($config) {
    $method = "imagecreatefrom{$config['type']}";
    $tmp = @$method(('imagecreatefromstring' === $method) ?
      file_get_contents($config['file']) : $config['file']);
    return $tmp;
  }

  /**
   * The .sprite() mixin implementation.
   * You can use it anywhere in your stylesheets, like so:
   * <code>
   * .sprite('path to an image icon relative to the current *.less file');
   * </code>
   * This will create a no-repeat icon with preset width and height.
   *
   * @param array $files @see this::resolveSprites
   * @param string $content The less css string.
   * @return string The less css string.
   */
  private function sprite($files, $content) {
    usort($files, function($a, $b) {
      if ($a['height'] === $b['height']) {
        if ($a['width'] === $b['width']) {
          return 0;
        }
        return ($a['width'] < $b['width']) ? -1 : 1;
      }
      return ($a['height'] < $b['height']) ? -1 : 1;
    });
    $rows = array(
      array(
        'width' => 0,
        'height' => 0,
        'items' => array(),
      ),
    );
    foreach ($files as $file) {
      $last = count($rows) - 1;
      if (($rows[$last]['width'] + $file['width']) > 1000) {
        $rows[] = array(
          'width' => 0,
          'height' => 0,
          'items' => array(),
        );
        $last++;
      }
      $rows[$last]['width'] += $file['width'];
      if ($file['height'] > $rows[$last]['height']) {
        $rows[$last]['height'] = $file['height'];
      }
      $rows[$last]['items'][] = $file;
    }
    $width = 0;
    $height = 0;
    $config = Service::get('config');
    foreach ($rows as &$row) {
      $left = 0;
      foreach ($row['items'] as &$item) {
        $item['left'] = $left;
        $item['top'] = $height;
        $item['css'] = "background-image:url('" . $config
            ->getPluginWebRoot('Al\Less') . "/img/sprite/sprite.png');" .
          'background-position:' . -$left . 'px ' . -$height . 'px;' .
          "background-repeat:no-repeat;width:{$item['width']}px;" .
          "height:{$item['height']}px;";
        $left += $item['width'];
        $content = str_replace($item['matches'], $item['css'], $content);
      }
      unset($item);
      if ($row['width'] > $width) {
        $width = $row['width'];
      }
      $height += $row['height'];
    }
    unset($row);
    $img = &$this->createImage($width, $height);
    foreach ($rows as $row) {
      foreach ($row['items'] as $item) {
        $tmp = &$this->createTemporaryImage($item);
        imagecopy($img, $tmp, $item['left'], $item['top'], 0, 0, $item['width'],
          $item['height']);
        imagedestroy($tmp);
      }
    }
    $this->saveImage($img, 'sprite');
    return $content;
  }

  /**
   * The .spritex() mixin implementation.
   * You can use it anywhere in your stylesheets, like so:
   * <code>
   * .spritex('path to an image icon relative to the current *.less file');
   * </code>
   * This will create a repeat-x icon with the preset height.
   *
   * @param array $files @see this::resolveSprites
   * @param string $content The less css string.
   * @return string The less css string.
   */
  private function spritex($files, $content) {
    usort($files, function($a, $b) {
      if ($a['height'] === $b['height']) {
        if ($a['width'] === $b['width']) {
          return 0;
        }
        return ($a['width'] < $b['width']) ? -1 : 1;
      }
      return ($a['height'] < $b['height']) ? -1 : 1;
    });
    $width = 0;
    $height = 0;
    $config = Service::get('config');
    foreach ($files as &$file) {
      $file['top'] = $height;
      /** @noinspection SpellCheckingInspection */
      $file['css'] = "background-image:url('" . $config
          ->getPluginWebRoot('Al\Less') . "/img/sprite/spritex.png');" .
        'background-position:0px ' . -$height . 'px;' .
        "background-repeat:repeat-x;height:{$file['height']}px;";
      if ($file['width'] > $width) {
        $width = $file['width'];
      }
      $height += $file['height'];
      $content = str_replace($file['matches'], $file['css'], $content);
    }
    unset($file);
    $img = &$this->createImage($width, $height);
    foreach ($files as $file) {
      $tmp = &$this->createTemporaryImage($file);
      $i = 0;
      while ($i < $width) {
        imagecopy($img, $tmp, $i, $file['top'], 0, 0, $file['width'],
          $file['height']);
        $i += $file['width'];
      }
      imagedestroy($tmp);
    }
    /** @noinspection SpellCheckingInspection */
    $this->saveImage($img, 'spritex');
    return $content;
  }

  /**
   * The .spritey() mixin implementation.
   * You can use it anywhere in your stylesheets, like so:
   * <code>
   * .spritey('path to an image icon relative to the current *.less file');
   * </code>
   * This will create a repeat-y icon with the preset width.
   *
   * @param array $files @see this::resolveSprites
   * @param string $content The less css string.
   * @return string The less css string.
   */
  private function spritey($files, $content) {
    usort($files, function($a, $b) {
      if ($a['width'] === $b['width']) {
        if ($a['height'] === $b['height']) {
          return 0;
        }
        return ($a['height'] < $b['height']) ? -1 : 1;
      }
      return ($a['width'] < $b['width']) ? -1 : 1;
    });
    $width = 0;
    $height = 0;
    $config = Service::get('config');
    foreach ($files as &$file) {
      $file['left'] = $width;
      /** @noinspection SpellCheckingInspection */
      $file['css'] = "background-image:url('" . $config
          ->getPluginWebRoot('Al\Less') . "/img/sprite/spritey.png');" .
        'background-position:' . -$width . 'px 0px;' .
        "background-repeat:repeat-y;width:{$file['width']}px;";
      if ($file['height'] > $height) {
        $height = $file['height'];
      }
      $width += $file['width'];
      $content = str_replace($file['matches'], $file['css'], $content);
    }
    unset($file);
    $img = &$this->createImage($width, $height);
    foreach ($files as $file) {
      $tmp = &$this->createTemporaryImage($file);
      $i = 0;
      while ($i < $height) {
        imagecopy($img, $tmp, $file['left'], $i, 0, 0, $file['width'],
          $file['height']);
        $i += $file['height'];
      }
      imagedestroy($tmp);
    }
    /** @noinspection SpellCheckingInspection */
    $this->saveImage($img, 'spritey');
    return $content;
  }

}